Build client UI as structured web components
-
What you'll see
A client-side Docly app starts as a single
index.htmlwith one bigapp.jsthat queries elements by id, builds markup with string concatenation, and wires up event handlers globally. It works at first. As the app grows — more screens, more lists, more forms calling more#/API/endpoints — the script becomes a tangle ofdocument.getElementById, duplicated render logic, and shared mutable state. A change to one widget breaks another, the same card markup is copy-pasted across pages, and nobody can tell which function owns which part of the DOM. -
What's actually happening
Docly serves plain HTML, CSS and JavaScript for client-side apps and handles the backend through
#/API/endpoints. Nothing about that requires — or excludes — a particular frontend architecture. The browser's own Web Components standard (custom elements, shadow DOM, templates) is available everywhere, needs no framework, no bundler and no build step, which makes it a natural fit for the zero-config Docly model.A custom element bundles three things that otherwise drift apart: the markup it renders, the behavior that reacts to user input, and the state it owns. Instead of a global script reaching into shared DOM, each element is responsible for its own subtree. Shadow DOM scopes its styles so one component's CSS can't leak into another's. The element exposes a small surface — attributes and properties in, events out — so pages compose features by placing tags, not by orchestrating imperative DOM code.
The payoff grows with the app. A
<customer-card>defined once renders identically on the dashboard, the search results and the detail page. A<data-table>that fetches from a#/API/endpoint can be dropped onto any page with a single attribute. Bugs stay local to the component that owns them, and a component can be tested in isolation because it has no hidden dependency on the rest of the page. -
What to do
Once a client-side app has more than a couple of repeated UI pieces or more than one page, define those pieces as custom elements rather than ad-hoc DOM scripts.
- One component per file — keep each
customElements.define(...)in its own JS file under your UI folder, named after the tag. - Encapsulate state — hold the component's data in instance fields, not global variables. Re-render from
render()when it changes. - Attributes in, events out — pass data down via attributes/properties; signal up via
this.dispatchEvent(new CustomEvent(...)). Don't reach across components. - Fetch with
~/— call endpoints asfetch('~/API/...')so the component is portable across working copies (see related entry). - Scope styles — use shadow DOM (or a clear class prefix) so component CSS doesn't leak.
Don't — one global script owning the whole DOM:
// app.js async function loadCustomers() { const res = await fetch('/API/customers'); // also wrong: leading slash const data = await res.json(); const list = document.getElementById('list'); list.innerHTML = data.map(c => `<div class="card">${c.name}</div>`).join(''); } loadCustomers();Do — a self-contained component:
// /ui/customer-list.js class CustomerList extends HTMLElement { connectedCallback() { this.load(); } async load() { const res = await fetch('~/API/customers'); this.customers = await res.json(); this.render(); } render() { this.innerHTML = ''; for (const c of this.customers) { const card = document.createElement('customer-card'); card.customer = c; this.appendChild(card); } } } customElements.define('customer-list', CustomerList);Compose pages by placing tags:
<!-- /ui/index.html --> <script src="/ui/customer-card.js"></script> <script src="/ui/customer-list.js"></script> <customer-list></customer-list>The same
<customer-card>now renders on every page that needs it, the list owns its own fetch-and-render cycle, and a change to either stays contained. The app scales by adding tags, not by growing one script. - One component per file — keep each
Last reviewed 28 May 2026